/*
 * (c) Copyright 2018 Palantir Technologies Inc. All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.palantir.async.initializer;

import com.palantir.common.concurrent.NamedThreadFactory;
import com.palantir.common.concurrent.PTExecutors;
import com.palantir.exception.NotInitializedException;
import com.palantir.logsafe.SafeArg;
import com.palantir.logsafe.logger.SafeLogger;
import com.palantir.logsafe.logger.SafeLoggerFactory;
import java.time.Duration;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.annotation.concurrent.ThreadSafe;

/**
 * Implements basic infrastructure to allow an object to be asynchronously initialized.
 * In order to be ThreadSafe, the abstract methods of the inheriting class need to be synchronized.
 */
@ThreadSafe
public abstract class AsyncInitializer {
    private static final SafeLogger log = SafeLoggerFactory.get(AsyncInitializer.class);

    private final ScheduledExecutorService singleThreadedExecutor = createExecutorService();
    private final AtomicBoolean isInitializing = new AtomicBoolean(false);
    private AsyncInitializationState state = new AsyncInitializationState();
    private int numberOfInitializationAttempts = 1;
    private Long initializationStartTime;

    /**
     * Initialization method that must be called to initialize the object before it is used.
     *
     * @param initializeAsync If true, the object will be initialized asynchronously when synchronous initialization
     * fails.
     */
    public final void initialize(boolean initializeAsync) {
        assertNeverCalledInitialize();

        initializationStartTime = System.currentTimeMillis();

        if (initializeAsync) {
            scheduleInitialization(Duration.ZERO);
        } else {
            attemptInitializationOnce();
        }
    }

    private void attemptInitializationOnce() {
        try {
            log.info("Attempting to initialize {}", SafeArg.of("className", getInitializingClassName()));
            tryInitializeInternal();
            log.info(
                    "Successfully initialized {} after {} milliseconds",
                    SafeArg.of("className", getInitializingClassName()),
                    SafeArg.of("initializationDuration", getMillisecondsSinceInitialization()));
        } catch (RuntimeException | Error throwable) {
            log.info(
                    "Failed to initialize {} after {} milliseconds",
                    SafeArg.of("className", getInitializingClassName()),
                    SafeArg.of("initializationDuration", getMillisecondsSinceInitialization()),
                    throwable);
            try {
                cleanUpOnInitFailure();
            } catch (RuntimeException | Error cleanupThrowable) {
                log.error(
                        "Failed to cleanup when initialization of {} failed after {} milliseconds",
                        SafeArg.of("className", getInitializingClassName()),
                        SafeArg.of("initializationDuration", getMillisecondsSinceInitialization()),
                        cleanupThrowable);
                throwable.addSuppressed(cleanupThrowable);
            }
            throw throwable;
        }
    }

    private void tryInitializationLoop() {
        if (state.isCancelled()) {
            log.info(
                    "Shutting down executor associated with asynchronous initialisation, as it was cancelled",
                    SafeArg.of("className", getInitializingClassName()),
                    SafeArg.of("numberOfAttempts", numberOfInitializationAttempts),
                    SafeArg.of("durationBeforeCancellation", getMillisecondsSinceInitialization()));
            singleThreadedExecutor.shutdown();
            return;
        }

        try {
            log.info(
                    "Attempting to initialize {} on the attempt {}. The amount of time elapsed since we began was {}"
                            + " milliseconds.",
                    SafeArg.of("className", getInitializingClassName()),
                    SafeArg.of("numberOfAttempts", numberOfInitializationAttempts),
                    SafeArg.of("initializationDuration", getMillisecondsSinceInitialization()));
            tryInitializeInternal();
            log.info(
                    "Initialized {} on the attempt {} in {} milliseconds",
                    SafeArg.of("className", getInitializingClassName()),
                    SafeArg.of("numberOfAttempts", numberOfInitializationAttempts),
                    SafeArg.of("initializationDuration", getMillisecondsSinceInitialization()));
        } catch (RuntimeException | Error throwable) {
            log.info(
                    "Failed to initialize {} on the attempt {}",
                    SafeArg.of("className", getInitializingClassName()),
                    SafeArg.of("numberOfAttempts", numberOfInitializationAttempts++),
                    throwable);
            try {
                cleanUpOnInitFailure();
            } catch (RuntimeException | Error cleanupThrowable) {
                log.error(
                        "Failed to cleanup when initialization of {} failed on attempt {} with {} milliseconds",
                        SafeArg.of("className", getInitializingClassName()),
                        SafeArg.of("numberOfAttempts", numberOfInitializationAttempts),
                        SafeArg.of("initializationDuration", getMillisecondsSinceInitialization()),
                        cleanupThrowable);
            }
            scheduleInitialization(sleepInterval());
        }
    }

    private long getMillisecondsSinceInitialization() {
        return System.currentTimeMillis() - initializationStartTime;
    }

    // Not final for tests.
    void scheduleInitialization(Duration delay) {
        singleThreadedExecutor.schedule(this::tryInitializationLoop, delay.toMillis(), TimeUnit.MILLISECONDS);
    }

    // Not final for tests.
    ScheduledExecutorService createExecutorService() {
        return PTExecutors.newSingleThreadScheduledExecutor(
                new NamedThreadFactory("AsyncInitializer-" + getInitializingClassName(), true));
    }

    // Not final for tests.
    void assertNeverCalledInitialize() {
        if (!isInitializing.compareAndSet(false, true)) {
            throw new IllegalStateException("Multiple calls tried to initialize the same instance.\n"
                    + "Each instance should have a single thread trying to initialize it.\n"
                    + "Object being initialized multiple times: " + getInitializingClassName());
        }
    }

    protected Duration sleepInterval() {
        return Duration.ofSeconds(10);
    }

    /**
     * Cancels future initializations and registers a callback to be called if the initialization is happening and
     * succeeds. If the initialization has already successfully completed, runs the cleanup task synchronously.
     * <p>
     * If the instance is closeable, it's recommended that the this method is invoked in a close call, and the callback
     * contains a call to the instance's close method.
     */
    protected final void cancelInitialization(Runnable handler) {
        if (state.initToCancelWithCleanupTask(handler) == AsyncInitializationState.State.DONE) {
            handler.run();
        }
    }

    protected final void checkInitialized() {
        if (!isInitialized()) {
            throw new NotInitializedException(getInitializingClassName());
        }
    }

    private void tryInitializeInternal() {
        tryInitialize();
        if (state.initToDone() == AsyncInitializationState.State.CANCELLED) {
            state.performCleanupTask();
        }
        singleThreadedExecutor.shutdown();
    }

    // Not final for tests.
    public boolean isInitialized() {
        return state.isDone();
    }

    /**
     * Override this method if there's anything to be cleaned up on initialization failure.
     * Default implementation is no-op.
     */
    protected void cleanUpOnInitFailure() {
        // no-op
    }

    /**
     * Override this method with the calls to initialize an object that may fail.
     * This method will be retried if any exception is thrown on its execution.
     * If there's any follow up action to clean any state left by a previous initialization failure, see
     * {@link AsyncInitializer#cleanUpOnInitFailure}.
     */
    protected abstract void tryInitialize();

    /**
     * This method should contain the name of the initializing class. It's used for logging and exception propagation.
     */
    protected abstract String getInitializingClassName();
}
